先前,我們把 main 放在 agent.py,而為了做到「架構分層」,應該要把它獨立出去才行!
這個部分:
# src/meowgent/agent.py ... if __name__ == "__main__": ...
接下來幾天,架構會變成這樣:
providers/ollama_provider.py 請求模型。agent.py 整合 provider。tool.py 負責工具函數。cli/main.py 作為程式進入點。這篇我們先來把 agent.py 升級成類別吧!
首先進行初始化的部分,未來建立 Agent 物件時會傳入 OllamaProvider 物件:
self.max_turns:限制迴圈輪數,為了防止模型因沒得到結果而無限調用工具。self.executor:是為了可以並發處理多個工具調用(模型同時傳來多個工具請求可同時執行)。# src/meowgent/agent.py
import re
from providers import LLMProvider, LLMResponse, ToolCall
from tool import execute_tool, TOOL_REGISTRY
from prompt import get_system_prompt
from typing import List, Callable, Optional, Iterator, Tuple
import time
import json
from concurrent.futures import ThreadPoolExecutor
class Agent():
def __init__(self, provider: LLMProvider, max_turns: int = 20):
self.provider = provider # 直接傳入 provider = OllamaProvider(model_name)
self.max_turns = max_turns
self.history_messages = []
self.executor = ThreadPoolExecutor(max_workers=4)
# 執行緒池(並發處理多個工具調用)
接著,我們建立 chat() 方法,提供未來製作的主程序來呼叫。
要做到的事有以下幾件:
main.py 取得輸入,加入多輪歷史。thinking_finish 進行推理總結,顯示推理耗時)。main.py 發送審核,並請求 tool.py 執行 execute_tool()。tool_approval 的部分,傳入的是一個「可呼叫函數」(從 main.py),要傳入此函數的參數為 str、dict,分別是工具名和工具要的參數,最後,回傳是否同意調用的 bool。
而 chat() 回傳的是可迭代的 LLMResponse。
# src/meowgent/agent.py
...
class Agent():
...
def chat(
self,
user_input: str,
tool_approval: Callable[[str, dict], bool]
) -> Iterator[LLMResponse]:
self.history_messages.append(
{
"role": "user",
"content": user_input
}
) # 使用者輸入加入多輪
我們來看一下關於 LLMResponse:
定義了六種狀態,以便之後在 main.py 判斷要去渲染什麼,除了狀態是必要的,其他都是視情況而選的:
content 是在推理("thinking")及回答("response")時才有。think_time 是在推理結束("thinking_done")時才有。tool_name 則是在工具調用結束("tool_executed" 或 "tool_rejected")時才有。# src/meowgent/providers/base.py
...
@dataclass
class LLMResponse:
status: Literal[
"response", "thinking", "thinking_done",
"tool_calling", "tool_executed", "tool_rejected"
]
content: Optional[str] = None
think_time: Optional[float] = None
tool_name: Optional[str] = None
一開始 turns 為 0,每進入一次迴圈,就 +1,直到達到限制次數(self.max_turns)為止,便不再繼續執行迴圈。
但若只是這樣突然地結束迴圈,模型最後一次調用依然停留在工具調用,還是沒能給出一個回答,所以我們需要在最後一次調用加入新 prompt 來提示「請給出一個回答,別再調用工具了」。
假設限制為 20 次,第 20 次(進入迴圈前
turns = 19),成功通過while turns < self.max_turns:接著被 +1,通過if is_last_turn:(turns == self.max_turns)給出最後一次機會的提示。
再來turns = 20就被擋住了,就算模型還是堅持要繼續也無法。
# src/meowgent/agent.py
...
class Agent():
...
def chat(...) -> Iterator[LLMResponse]:
...
turns = 0
while turns < self.max_turns: # 模型內迴圈,使用者輸入,模型多次調用
turns += 1 # 計數 +1
is_last_turn = (turns == self.max_turns) # 是否為最後一輪
temp_history_messages = list(self.history_messages)
# 複製一份,阻止無限調用訊息進入多輪歷史
if is_last_turn:
temp_history_messages.append(
{
"role": "user",
"content": "
[系統提示] 您已達到工具調用次數上限。請勿再調用工具,
直接輸出最終回答,並總結當前進度與遇到的問題。
"
}
)
呼叫 ollama_provider.py 中的 stream_generate() 來調用模型,並且初始化變數:
response為Iterator[StreamChunk]形式,之後用for取內容。
# src/meowgent/agent.py
...
class Agent():
...
def chat(...) -> Iterator[LLMResponse]:
...
while turns < self.max_turns:
...
if is_last_turn:
...
response = self.provider.stream_generate(
history_messages=temp_history_messages
) # 請求模型
thinking_full_text = "" # 存放推理內容
result_full_text = "" # 存放回答及工具調用請求
is_thinking = False # 是否處於推理狀態
think_start_time = None # 推理計時
tools_result: List[Tuple] = [] # 存放 [(ToolCall 物件, 執行結果), ...]
completed_tools: List[str] = [] # 存放已閉合的工具 raw 字串(下一篇用到的)
回傳的值是 LLMResponse 形式的,狀態為 "thinking_done" 且包含時間;又或者是 None:nonlocal is_thinking, think_start_time 的用意是可以修改到此函數(thinking_finish())外部(上一層)的變數。
而
global是用來修改最外層的變數。
首先,先判斷是否處於推理結束階段,是的對 think_time、think_start_time 做處理(等下馬上解釋計時部分),並回傳 LLMResponse。
不處在推理結束(沒通過 if is_thinking:),則回傳 None。
# src/meowgent/agent.py
...
class Agent():
...
def chat(...) -> Iterator[LLMResponse]:
...
while turns < self.max_turns:
...
tools_result = ...
def thinking_finish() -> Optional[LLMResponse]:
""" 判斷推理階段是否結束,若結束則計算並回傳推理耗時 """
nonlocal is_thinking, think_start_time
if is_thinking: # 表示為推理結束後進到回答或工具調用階段
think_time = round(time.perf_counter() - think_start_time, 1)
# 更新計時
is_thinking = False # 把狀態關掉
return LLMResponse(
status="thinking_done",
think_time=think_time
)
return None
分為兩種,推理文字和回答文字(包含工具調用請求)。
我們遍歷 response 取出每個 chunk。
StreamChunk 嗎?也就是 stream_generate() 回傳的,.thinking_chunk 正是來自此。chunk.thinking_chunk 時,把 is_thinking 狀態打開(設 True),接著,如果這是此次調用模型回傳的第一個推理 chunk,用 time.perf_counter() 開始計時,否則不做動作,這裡就是開始計時的邏輯。thinking_full_text 裡,並用 LLMResponse 回傳。
你可能想問,為什麼要把文字內容「不斷地疊加做回傳」,而不是只回傳這個 chunk 的內容?這是因為,在終端顯示內容時我們採用
live.update()來做更新(實際上不完全採用live.update(),但可以先這麼理解),而它的機制是可以想像現在手上只有一塊畫板,每次寫上新東西,舊的就要擦掉,這就是為什麼要用疊加的方式做回傳。
# src/meowgent/agent.py
...
class Agent():
...
def chat(...) -> Iterator[LLMResponse]:
...
while turns < self.max_turns:
...
def thinking_finish():
...
for chunk in response:
if chunk.thinking_chunk: # 推理
is_thinking = True
think_start_time = time.perf_counter() if think_start_time is None else think_start_time
# 如果沒開始計時(此次推理第一個 token 出現時)開始計時
# 流式輸出
thinking_full_text += chunk.thinking_chunk
yield LLMResponse(
status="thinking",
content=thinking_full_text
)
thinking_finish(),若稍早有進到推理 is_thinking 就已經被設為 True 了,如此便成功進到 if is_thinking: 區塊,用 time.perf_counter() 記錄下推理結束時間扣掉上面推理階段的 think_start_time = time.perf_counter() if ... 所記錄的開始時間,再用 round() 做「四捨六入五成雙」(否則計算出來的時間會是多位小數點的浮點數),然後 yield 先行回傳思考時間,接著,我們先把目前輸出的內容給加進 result_full_text。# src/meowgent/agent.py
...
class Agent():
...
def chat(...) -> Iterator[LLMResponse]:
...
while turns < self.max_turns:
...
for chunk in response:
if chunk.thinking_chunk:
...
if chunk.content_chunk: # 回答
thinking_status = thinking_finish()
if thinking_status:
yield thinking_status
result_full_text += chunk.content_chunk
有沒有發現,這裡只把 result_full_text 做累加,還沒有回傳?
沒錯!下一篇,就是要來對 result_full_text 做處理(把工具調用從回答中分離出來)。